昨天我們讓程式在出錯時不會停下來。今天要解決另一個更根本的限制:
程式一關掉,所有東西都不見了。
Day 6 我們用 List 存了一堆待辦事項,Day 7 把它包成 Function。但只要按下 Ctrl+C,那些資料就消失了。下次打開程式,又是一張白紙。
對一個「生活助理」來說,這是致命的。使用者今天告訴它「我早上七點起床」,明天它就忘了,那它永遠只是一個問答機器,不是助理。
今天要做的,就是讓資料離開記憶體,寫進硬碟。
先把概念講清楚:
| 記憶體(RAM) | 硬碟(檔案) | |
|---|---|---|
| 速度 | 快 | 慢 |
| 程式關掉後 | 消失 | 留著 |
| 在 Python 裡 | 變數、List、Dictionary | 檔案 |
所以「儲存資料」這件事,本質上就是:把記憶體裡的變數,用某種格式寫成檔案;下次啟動時再讀回來。
Python 用 open() 開檔案,最常用的三種模式:
open("notes.txt", "r") # read 讀取(檔案必須存在)
open("notes.txt", "w") # write 寫入(會清空原內容!)
open("notes.txt", "a") # append 附加(接在原內容後面)
⚠️ "w" 是一個很容易踩雷的模式——它會把原本的檔案內容整個清空。如果你只是想加一行,請用 "a"。
另外,處理中文時請一定要加上 encoding="utf-8",尤其在 Windows 上,不加常常會變成亂碼或直接報錯:
open("notes.txt", "r", encoding="utf-8")
這個坑我自己踩過,特別標出來。
最基本的寫法:
f = open("notes.txt", "w", encoding="utf-8")
f.write("今天學了檔案讀寫\n")
f.write("明天要學 JSON\n")
f.close()
注意兩件事:
write() 不會自動換行,要自己加 \n。close(),不然資料可能還卡在緩衝區沒真正寫進去。但手動 close() 很容易忘記,而且如果中間出錯(回想昨天的內容),close() 就不會被執行。所以 Python 提供了更好的寫法:
with open("notes.txt", "w", encoding="utf-8") as f:
f.write("今天學了檔案讀寫\n")
f.write("明天要學 JSON\n")
# 離開 with 區塊時,Python 自動幫你 close()
with 的意思是「在這個區塊裡使用這個資源,離開時自動收拾」。就算區塊裡面出錯,檔案一樣會被正確關閉——它內建了昨天講的 finally 精神。
以後開檔案,一律用 with,不要再手動 close() 了。
lines = ["買牛奶\n", "運動 30 分鐘\n", "寫鐵人賽文章\n"]
with open("todos.txt", "w", encoding="utf-8") as f:
f.writelines(lines)
或者用迴圈(我自己比較常用這種,因為比較好控制格式):
todos = ["買牛奶", "運動 30 分鐘", "寫鐵人賽文章"]
with open("todos.txt", "w", encoding="utf-8") as f:
for todo in todos:
f.write(f"{todo}\n")
讀取有三種常見方式:
# 方式 1:整個檔案讀成一個字串
with open("todos.txt", "r", encoding="utf-8") as f:
content = f.read()
print(content)
# 方式 2:讀成一個 List,每行一個元素
with open("todos.txt", "r", encoding="utf-8") as f:
lines = f.readlines()
print(lines)
# ['買牛奶\n', '運動 30 分鐘\n', '寫鐵人賽文章\n']
# 方式 3:一行一行讀(檔案很大時用這個,最省記憶體)
with open("todos.txt", "r", encoding="utf-8") as f:
for line in f:
print(line.strip()) # strip() 去掉頭尾的空白和換行
方式 3 是最推薦的——它不會一次把整個檔案塞進記憶體,而且搭配 Day 5 的迴圈觀念很自然。
從檔案讀出來的每一行結尾都會帶著 \n,如果直接拿去比對,會很困惑:
line = "買牛奶\n"
print(line == "買牛奶") # False ← 為什麼!
print(line.strip() == "買牛奶") # True
.strip() 去掉頭尾空白字元,.rstrip() 只去掉右邊。這是新手最常卡住的地方之一。
昨天學的例外處理,今天就派上用場了:
def load_todos(path):
"""從檔案讀取待辦清單。檔案不存在時回傳空清單。"""
try:
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
except FileNotFoundError:
return []
todos = load_todos("todos.txt")
print(f"載入 {len(todos)} 筆待辦")
這個設計很重要:第一次執行程式時檔案一定不存在。與其讓程式崩潰,不如把「檔案不存在」解讀成「還沒有任何資料」,回傳一個空清單。
這裡也順便用到了 Day 6 的 List 綜合表達式(list comprehension):
[line.strip() for line in f if line.strip()]
意思是:把檔案的每一行去掉空白,而且只留下非空的行。
字串拼路徑在 Windows 和 Mac / Linux 上會不一樣(\ vs /),很容易出錯。Python 內建的 pathlib 幫你解決這件事:
from pathlib import Path
# 建立路徑物件
data_dir = Path("data")
todo_file = data_dir / "todos.txt" # 用 / 串接,跨平台都正確
# 常用操作
data_dir.mkdir(exist_ok=True) # 建立資料夾,已存在就不報錯
print(todo_file.exists()) # 檔案是否存在
print(todo_file.suffix) # .txt
print(todo_file.stem) # todos
# 直接讀寫(超方便,不用 with)
todo_file.write_text("買牛奶\n運動\n", encoding="utf-8")
content = todo_file.read_text(encoding="utf-8")
Path.write_text() 和 read_text() 適合小檔案,一行搞定。大檔案還是用 with open() 逐行處理比較好。
mkdir(exist_ok=True) 這個參數也很實用——確保資料夾存在,但已經存在時不要報錯。
把今天學的東西整合起來,做一個真的能用的小工具:
from pathlib import Path
DATA_DIR = Path("data")
TODO_FILE = DATA_DIR / "todos.txt"
def load_todos():
"""讀取待辦清單。"""
if not TODO_FILE.exists():
return []
with open(TODO_FILE, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def save_todos(todos):
"""把待辦清單寫回檔案。"""
DATA_DIR.mkdir(exist_ok=True)
with open(TODO_FILE, "w", encoding="utf-8") as f:
for todo in todos:
f.write(f"{todo}\n")
def add_todo(title):
"""新增一筆待辦。"""
if not title.strip():
raise ValueError("標題不能是空的")
todos = load_todos()
todos.append(title.strip())
save_todos(todos)
return todos
def main():
print("=== 待辦小幫手 ===")
print("指令:add <內容> / list / quit\n")
while True:
command = input("> ").strip()
if command == "quit":
print("掰掰")
break
elif command == "list":
todos = load_todos()
if not todos:
print("(目前沒有待辦)")
for i, todo in enumerate(todos, start=1):
print(f"{i}. {todo}")
elif command.startswith("add "):
title = command[4:]
try:
todos = add_todo(title)
print(f"已新增,目前共 {len(todos)} 筆")
except ValueError as e:
print(f"新增失敗:{e}")
else:
print("看不懂這個指令")
if __name__ == "__main__":
main()
執行看看:
=== 待辦小幫手 ===
指令:add <內容> / list / quit
> add 買牛奶
已新增,目前共 1 筆
> add 寫鐵人賽文章
已新增,目前共 2 筆
> list
1. 買牛奶
2. 寫鐵人賽文章
> quit
掰掰
關掉程式,再重新執行一次,輸入 list:
> list
1. 買牛奶
2. 寫鐵人賽文章
資料還在。這是今天最重要的一刻——我們的程式第一次有了「記憶」。
檔案讀寫看起來很基礎,但它其實是 Agent 架構裡好幾個關鍵功能的底層:
1. 長期記憶(Long-term memory)
Agent 需要記住使用者的偏好:「我不吃牛肉」、「我習慣晚上運動」、「提醒我請用中文」。這些東西不能每次對話都重講一次,必須存在某個地方,下次啟動時讀回來。
2. 對話歷史(Conversation history)
多輪對話中,Agent 要記得前面講過什麼。如果我們希望「今天的對話明天還記得」,就要把對話存檔。
3. 執行日誌(Log)
Agent 做了哪些決定、呼叫了哪些工具、結果如何——這些要寫下來,才能事後檢查它為什麼做了某個決定。(Day 17 會專門講這個)
4. 快取(Cache)
天氣 API 十分鐘內問三次,答案都一樣,沒必要真的打三次。把結果存成檔案,設一個有效期限,就省下了時間和 API 額度。
今天寫的 load_todos() / save_todos() 這組函式,結構上跟真正的 Agent 記憶模組是一樣的:一個負責讀進來,一個負責寫回去,中間的程式碼只管操作記憶體裡的資料。
不過用純文字檔有個大問題:結構化資料存不進去。
我們的待辦事項如果不只有標題,還有優先度、完成狀態、建立時間,用 txt 要怎麼存?用逗號分隔嗎?那內容本身有逗號怎麼辦?
這就是明天要講的 JSON 要解決的問題。
with open(...) as f: 開檔案,會自動幫你關檔,就算出錯也一樣encoding="utf-8"
"w" 會清空檔案,想附加請用 "a"
.strip()
try/except FileNotFoundError 回傳空資料,不要讓程式崩潰pathlib.Path 讓路徑處理跨平台又好讀明天我們要讓資料「有結構」:JSON 與 Agent 的共通語言。